diff --git a/web/components/avatar.css b/web/avatars/avatar.css
similarity index 100%
rename from web/components/avatar.css
rename to web/avatars/avatar.css
diff --git a/web/components/avatar.react.js b/web/avatars/avatar.react.js
similarity index 99%
rename from web/components/avatar.react.js
rename to web/avatars/avatar.react.js
index 14eb0a685..108f9ade1 100644
--- a/web/components/avatar.react.js
+++ b/web/avatars/avatar.react.js
@@ -1,69 +1,69 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
-import type { ResolvedClientAvatar } from 'lib/types/avatar-types';
+import type { ResolvedClientAvatar } from 'lib/types/avatar-types.js';
import css from './avatar.css';
type Props = {
+avatarInfo: ResolvedClientAvatar,
+size: 'micro' | 'small' | 'large' | 'profile',
};
function Avatar(props: Props): React.Node {
const { avatarInfo, size } = props;
const containerSizeClassName = classnames({
[css.imgContainer]: avatarInfo.type === 'image',
[css.micro]: size === 'micro',
[css.small]: size === 'small',
[css.large]: size === 'large',
[css.profile]: size === 'profile',
});
const emojiSizeClassName = classnames({
[css.emojiContainer]: true,
[css.emojiMicro]: size === 'micro',
[css.emojiSmall]: size === 'small',
[css.emojiLarge]: size === 'large',
[css.emojiProfile]: size === 'profile',
});
const emojiContainerColorStyle = React.useMemo(() => {
if (avatarInfo.type === 'emoji') {
return { backgroundColor: `#${avatarInfo.color}` };
}
return undefined;
}, [avatarInfo.color, avatarInfo.type]);
const avatar = React.useMemo(() => {
if (avatarInfo.type === 'image') {
return (
);
}
return (
);
}, [
avatarInfo.emoji,
avatarInfo.type,
avatarInfo.uri,
containerSizeClassName,
emojiContainerColorStyle,
emojiSizeClassName,
]);
return avatar;
}
export default Avatar;
diff --git a/web/components/thread-avatar.react.js b/web/avatars/thread-avatar.react.js
similarity index 100%
rename from web/components/thread-avatar.react.js
rename to web/avatars/thread-avatar.react.js
diff --git a/web/components/user-avatar.react.js b/web/avatars/user-avatar.react.js
similarity index 100%
rename from web/components/user-avatar.react.js
rename to web/avatars/user-avatar.react.js
diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js
index a4c65fdc9..6a7b15dcb 100644
--- a/web/chat/chat-thread-composer.react.js
+++ b/web/chat/chat-thread-composer.react.js
@@ -1,211 +1,211 @@
// @flow
import classNames from 'classnames';
import _isEqual from 'lodash/fp/isEqual.js';
import * as React from 'react';
import { useDispatch } from 'react-redux';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useENSNames } from 'lib/hooks/ens-cache.js';
import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors.js';
import {
getPotentialMemberItems,
useSearchUsers,
} from 'lib/shared/search-utils.js';
import { threadIsPending } from 'lib/shared/thread-utils.js';
import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js';
import css from './chat-thread-composer.css';
+import UserAvatar from '../avatars/user-avatar.react.js';
import Button from '../components/button.react.js';
import Label from '../components/label.react.js';
import Search from '../components/search.react.js';
-import UserAvatar from '../components/user-avatar.react.js';
import type { InputState } from '../input/input-state.js';
import { updateNavInfoActionType } from '../redux/action-types.js';
import { useSelector } from '../redux/redux-utils.js';
type Props = {
+userInfoInputArray: $ReadOnlyArray,
+otherUserInfos: { [id: string]: AccountUserInfo },
+threadID: string,
+inputState: InputState,
};
type ActiveThreadBehavior =
| 'reset-active-thread-if-pending'
| 'keep-active-thread';
function ChatThreadComposer(props: Props): React.Node {
const { userInfoInputArray, otherUserInfos, threadID, inputState } = props;
const [usernameInputText, setUsernameInputText] = React.useState('');
const dispatch = useDispatch();
const userSearchIndex = useSelector(userSearchIndexForPotentialMembers);
const userInfoInputIDs = React.useMemo(
() => userInfoInputArray.map(userInfo => userInfo.id),
[userInfoInputArray],
);
const serverSearchResults = useSearchUsers(usernameInputText);
const userListItems = React.useMemo(
() =>
getPotentialMemberItems({
text: usernameInputText,
userInfos: otherUserInfos,
searchIndex: userSearchIndex,
excludeUserIDs: userInfoInputIDs,
includeServerSearchUsers: serverSearchResults,
}),
[
usernameInputText,
otherUserInfos,
userSearchIndex,
userInfoInputIDs,
serverSearchResults,
],
);
const userListItemsWithENSNames = useENSNames(userListItems);
const onSelectUserFromSearch = React.useCallback(
(user: AccountUserInfo) => {
dispatch({
type: updateNavInfoActionType,
payload: {
selectedUserList: [...userInfoInputArray, user],
},
});
setUsernameInputText('');
},
[dispatch, userInfoInputArray],
);
const onRemoveUserFromSelected = React.useCallback(
(userID: string) => {
const newSelectedUserList = userInfoInputArray.filter(
({ id }) => userID !== id,
);
if (_isEqual(userInfoInputArray)(newSelectedUserList)) {
return;
}
dispatch({
type: updateNavInfoActionType,
payload: {
selectedUserList: newSelectedUserList,
},
});
},
[dispatch, userInfoInputArray],
);
const userSearchResultList = React.useMemo(() => {
if (
!userListItemsWithENSNames.length ||
(!usernameInputText && userInfoInputArray.length)
) {
return null;
}
const userItems = userListItemsWithENSNames.map(
(userSearchResult: UserListItem) => {
const { alertTitle, alertText, notice, disabled, ...accountUserInfo } =
userSearchResult;
return (
);
},
);
return ;
}, [
onSelectUserFromSearch,
userInfoInputArray.length,
userListItemsWithENSNames,
usernameInputText,
]);
const hideSearch = React.useCallback(
(threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => {
dispatch({
type: updateNavInfoActionType,
payload: {
chatMode: 'view',
activeChatThreadID:
threadBehavior === 'keep-active-thread' ||
!threadIsPending(threadID)
? threadID
: null,
},
});
},
[dispatch, threadID],
);
const onCloseSearch = React.useCallback(() => {
hideSearch('reset-active-thread-if-pending');
}, [hideSearch]);
const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray);
const tagsList = React.useMemo(() => {
if (!userInfoInputArrayWithENSNames?.length) {
return null;
}
const labels = userInfoInputArrayWithENSNames.map(user => {
return (
);
});
return {labels}
;
}, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]);
React.useEffect(() => {
if (!inputState) {
return undefined;
}
inputState.registerSendCallback(hideSearch);
return () => inputState.unregisterSendCallback(hideSearch);
}, [hideSearch, inputState]);
const threadSearchContainerStyles = classNames(css.threadSearchContainer, {
[css.fullHeight]: !userInfoInputArray.length,
});
return (
{tagsList}
{userSearchResultList}
);
}
export default ChatThreadComposer;
diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js
index 6e0a4b215..c9b4dd53c 100644
--- a/web/chat/chat-thread-list-item.react.js
+++ b/web/chat/chat-thread-list-item.react.js
@@ -1,161 +1,161 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js';
import { useAncestorThreads } from 'lib/shared/ancestor-threads.js';
import { shortAbsoluteDate } from 'lib/utils/date-utils.js';
import {
useResolvedThreadInfo,
useResolvedThreadInfos,
} from 'lib/utils/entity-helpers.js';
import ChatThreadListItemMenu from './chat-thread-list-item-menu.react.js';
import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js';
import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js';
import css from './chat-thread-list.css';
import MessagePreview from './message-preview.react.js';
-import ThreadAvatar from '../components/thread-avatar.react.js';
+import ThreadAvatar from '../avatars/thread-avatar.react.js';
import { useSelector } from '../redux/redux-utils.js';
import {
useOnClickThread,
useThreadIsActive,
} from '../selectors/thread-selectors.js';
type Props = {
+item: ChatThreadItem,
};
function ChatThreadListItem(props: Props): React.Node {
const { item } = props;
const {
threadInfo,
lastUpdatedTimeIncludingSidebars,
mostRecentNonLocalMessage,
mostRecentMessageInfo,
} = item;
const { id: threadID, currentUser } = threadInfo;
const unresolvedAncestorThreads = useAncestorThreads(threadInfo);
const ancestorThreads = useResolvedThreadInfos(unresolvedAncestorThreads);
const lastActivity = shortAbsoluteDate(lastUpdatedTimeIncludingSidebars);
const active = useThreadIsActive(threadID);
const isCreateMode = useSelector(
state => state.navInfo.chatMode === 'create',
);
const onClick = useOnClickThread(item.threadInfo);
const selectItemIfNotActiveCreation = React.useCallback(
(event: SyntheticEvent) => {
if (!isCreateMode || !active) {
onClick(event);
}
},
[isCreateMode, active, onClick],
);
const containerClassName = classNames({
[css.thread]: true,
[css.activeThread]: active,
});
const { unread } = currentUser;
const titleClassName = classNames({
[css.title]: true,
[css.unread]: unread,
});
const lastActivityClassName = classNames({
[css.lastActivity]: true,
[css.unread]: unread,
[css.dark]: !unread,
});
const breadCrumbsClassName = classNames(css.breadCrumbs, {
[css.unread]: unread,
});
let unreadDot;
if (unread) {
unreadDot = ;
}
const sidebars = item.sidebars.map((sidebarItem, index) => {
if (sidebarItem.type === 'sidebar') {
const { type, ...sidebarInfo } = sidebarItem;
return (
0}
key={sidebarInfo.threadInfo.id}
/>
);
} else if (sidebarItem.type === 'seeMore') {
return (
);
} else {
return ;
}
});
const ancestorPath = ancestorThreads.map((thread, idx) => {
const isNotLast = idx !== ancestorThreads.length - 1;
const chevron = isNotLast && (
);
return (
{thread.uiName}
{chevron}
);
});
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
<>
{sidebars}
>
);
}
export default ChatThreadListItem;
diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js
index 71941e232..47094ea57 100644
--- a/web/chat/composed-message.react.js
+++ b/web/chat/composed-message.react.js
@@ -1,240 +1,240 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import {
Circle as CircleIcon,
CheckCircle as CheckCircleIcon,
XCircle as XCircleIcon,
} from 'react-feather';
import { useStringForUser } from 'lib/hooks/ens-cache.js';
import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
import { getMessageLabel } from 'lib/shared/edit-messages-utils.js';
import { assertComposableMessageType } from 'lib/types/message-types.js';
import { type ThreadInfo } from 'lib/types/thread-types.js';
import css from './chat-message-list.css';
import FailedSend from './failed-send.react.js';
import InlineEngagement from './inline-engagement.react.js';
+import UserAvatar from '../avatars/user-avatar.react.js';
import CommIcon from '../CommIcon.react.js';
-import UserAvatar from '../components/user-avatar.react.js';
import { type InputState, InputStateContext } from '../input/input-state.js';
import { useMessageTooltip } from '../utils/tooltip-action-utils.js';
import { tooltipPositions } from '../utils/tooltip-utils.js';
const availableTooltipPositionsForViewerMessage = [
tooltipPositions.LEFT,
tooltipPositions.LEFT_BOTTOM,
tooltipPositions.LEFT_TOP,
tooltipPositions.RIGHT,
tooltipPositions.RIGHT_BOTTOM,
tooltipPositions.RIGHT_TOP,
tooltipPositions.BOTTOM,
tooltipPositions.TOP,
];
const availableTooltipPositionsForNonViewerMessage = [
tooltipPositions.RIGHT,
tooltipPositions.RIGHT_BOTTOM,
tooltipPositions.RIGHT_TOP,
tooltipPositions.LEFT,
tooltipPositions.LEFT_BOTTOM,
tooltipPositions.LEFT_TOP,
tooltipPositions.BOTTOM,
tooltipPositions.TOP,
];
type BaseProps = {
+item: ChatMessageInfoItem,
+threadInfo: ThreadInfo,
+shouldDisplayPinIndicator: boolean,
+sendFailed: boolean,
+children: React.Node,
+fixedWidth?: boolean,
+borderRadius: number,
};
type BaseConfig = React.Config;
type Props = {
...BaseProps,
// withInputState
+inputState: ?InputState,
+onMouseLeave: ?() => mixed,
+onMouseEnter: (event: SyntheticEvent) => mixed,
+containsInlineEngagement: boolean,
+stringForUser: ?string,
};
class ComposedMessage extends React.PureComponent {
static defaultProps: { +borderRadius: number } = {
borderRadius: 8,
};
render(): React.Node {
assertComposableMessageType(this.props.item.messageInfo.type);
const { borderRadius, item, threadInfo, shouldDisplayPinIndicator } =
this.props;
const { hasBeenEdited, isPinned } = item;
const { id, creator } = item.messageInfo;
const threadColor = threadInfo.color;
const { isViewer } = creator;
const contentClassName = classNames({
[css.content]: true,
[css.viewerContent]: isViewer,
[css.nonViewerContent]: !isViewer,
});
const messageBoxContainerClassName = classNames({
[css.messageBoxContainer]: true,
[css.fixedWidthMessageBoxContainer]: this.props.fixedWidth,
});
const messageBoxClassName = classNames({
[css.messageBox]: true,
[css.fixedWidthMessageBox]: this.props.fixedWidth,
});
const messageBoxStyle = {
borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius,
borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius,
borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius,
borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius,
};
let authorName = null;
const { stringForUser } = this.props;
if (stringForUser) {
authorName = {stringForUser};
}
let deliveryIcon = null;
let failedSendInfo = null;
if (isViewer) {
let deliveryIconSpan;
let deliveryIconColor = threadColor;
if (id !== null && id !== undefined) {
deliveryIconSpan = ;
} else if (this.props.sendFailed) {
deliveryIconSpan = ;
deliveryIconColor = 'FF0000';
failedSendInfo = ;
} else {
deliveryIconSpan = ;
}
deliveryIcon = (
{deliveryIconSpan}
);
}
let inlineEngagement = null;
const label = getMessageLabel(hasBeenEdited, threadInfo);
if (
(this.props.containsInlineEngagement && item.threadCreatedFromMessage) ||
Object.keys(item.reactions).length > 0 ||
label
) {
const positioning = isViewer ? 'right' : 'left';
inlineEngagement = (
);
}
let avatar;
if (!isViewer && item.endsCluster) {
avatar = (
);
} else if (!isViewer) {
avatar = ;
}
const pinIconPositioning = isViewer ? 'left' : 'right';
const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin';
const pinIconContainerClassName = classNames({
[css.pinIconContainer]: true,
[css.pinIconLeft]: pinIconPositioning === 'left',
[css.pinIconRight]: pinIconPositioning === 'right',
});
let pinIcon;
if (isPinned && shouldDisplayPinIndicator) {
pinIcon = (
);
}
return (
{authorName}
{avatar}
{pinIcon}
{this.props.children}
{deliveryIcon}
{failedSendInfo}
{inlineEngagement}
);
}
}
type ConnectedConfig = React.Config<
BaseProps,
typeof ComposedMessage.defaultProps,
>;
const ConnectedComposedMessage: React.ComponentType =
React.memo(function ConnectedComposedMessage(props) {
const { item, threadInfo } = props;
const inputState = React.useContext(InputStateContext);
const { creator } = props.item.messageInfo;
const { isViewer } = creator;
const availablePositions = isViewer
? availableTooltipPositionsForViewerMessage
: availableTooltipPositionsForNonViewerMessage;
const containsInlineEngagement = !!item.threadCreatedFromMessage;
const { onMouseLeave, onMouseEnter } = useMessageTooltip({
item,
threadInfo,
availablePositions,
});
const shouldShowUsername = !isViewer && item.startsCluster;
const stringForUser = useStringForUser(shouldShowUsername ? creator : null);
return (
);
});
export default ConnectedComposedMessage;
diff --git a/web/chat/thread-top-bar.react.js b/web/chat/thread-top-bar.react.js
index 81269a12d..b9054cc9e 100644
--- a/web/chat/thread-top-bar.react.js
+++ b/web/chat/thread-top-bar.react.js
@@ -1,82 +1,82 @@
// @flow
import * as React from 'react';
import { ChevronRight } from 'react-feather';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { threadIsPending } from 'lib/shared/thread-utils.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import ThreadMenu from './thread-menu.react.js';
import css from './thread-top-bar.css';
-import ThreadAvatar from '../components/thread-avatar.react.js';
+import ThreadAvatar from '../avatars/thread-avatar.react.js';
import { InputStateContext } from '../input/input-state.js';
import MessageResultsModal from '../modals/chat/message-results-modal.react.js';
type ThreadTopBarProps = {
+threadInfo: ThreadInfo,
};
function ThreadTopBar(props: ThreadTopBarProps): React.Node {
const { threadInfo } = props;
const { pushModal } = useModalContext();
let threadMenu = null;
if (!threadIsPending(threadInfo.id)) {
threadMenu = ;
}
// To allow the pinned messages modal to be re-used by the message search
// modal, it will be useful to make the modal accept a prop that defines it's
// name, instead of setting it directly in the modal.
const bannerText = React.useMemo(() => {
if (!threadInfo.pinnedCount || threadInfo.pinnedCount === 0) {
return '';
}
const messageNoun = threadInfo.pinnedCount === 1 ? 'message' : 'messages';
return `${threadInfo.pinnedCount} pinned ${messageNoun}`;
}, [threadInfo.pinnedCount]);
const inputState = React.useContext(InputStateContext);
const pushThreadPinsModal = React.useCallback(() => {
pushModal(
,
);
}, [pushModal, inputState, threadInfo, bannerText]);
const pinnedCountBanner = React.useMemo(() => {
if (!bannerText) {
return null;
}
return (
);
}, [bannerText, pushThreadPinsModal]);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
<>
{pinnedCountBanner}
>
);
}
export default ThreadTopBar;
diff --git a/web/modals/chat/message-reactions-modal.react.js b/web/modals/chat/message-reactions-modal.react.js
index 5d63d1f46..b22ebb556 100644
--- a/web/modals/chat/message-reactions-modal.react.js
+++ b/web/modals/chat/message-reactions-modal.react.js
@@ -1,43 +1,43 @@
// @flow
import * as React from 'react';
import type { ReactionInfo } from 'lib/selectors/chat-selectors.js';
import { useMessageReactionsList } from 'lib/shared/reaction-utils.js';
import css from './message-reactions-modal.css';
-import UserAvatar from '../../components/user-avatar.react.js';
+import UserAvatar from '../../avatars/user-avatar.react.js';
import Modal from '../modal.react.js';
type Props = {
+onClose: () => void,
+reactions: ReactionInfo,
};
function MessageReactionsModal(props: Props): React.Node {
const { onClose, reactions } = props;
const messageReactionsList = useMessageReactionsList(reactions);
const reactionsList = React.useMemo(
() =>
messageReactionsList.map(messageReactionUser => (
{messageReactionUser.username}
{messageReactionUser.reaction}
)),
[messageReactionsList],
);
return (
{reactionsList}
);
}
export default MessageReactionsModal;
diff --git a/web/modals/components/add-members-item.react.js b/web/modals/components/add-members-item.react.js
index e9d80ed70..699ce8afc 100644
--- a/web/modals/components/add-members-item.react.js
+++ b/web/modals/components/add-members-item.react.js
@@ -1,55 +1,55 @@
// @flow
import * as React from 'react';
import type { UserListItem } from 'lib/types/user-types.js';
import css from './add-members.css';
+import UserAvatar from '../../avatars/user-avatar.react.js';
import Button from '../../components/button.react.js';
-import UserAvatar from '../../components/user-avatar.react.js';
type AddMembersItemProps = {
+userInfo: UserListItem,
+onClick: (userID: string) => void,
+userAdded: boolean,
};
function AddMemberItem(props: AddMembersItemProps): React.Node {
const { userInfo, onClick, userAdded = false } = props;
const canBeAdded = !userInfo.alertText;
const onClickCallback = React.useCallback(() => {
if (!canBeAdded) {
return;
}
onClick(userInfo.id);
}, [canBeAdded, onClick, userInfo.id]);
const action = React.useMemo(() => {
if (!canBeAdded) {
return userInfo.alertTitle;
}
if (userAdded) {
return Remove;
} else {
return 'Add';
}
}, [canBeAdded, userAdded, userInfo.alertTitle]);
return (
);
}
export default AddMemberItem;
diff --git a/web/modals/threads/members/member.react.js b/web/modals/threads/members/member.react.js
index e76075ba7..55ed7e0fd 100644
--- a/web/modals/threads/members/member.react.js
+++ b/web/modals/threads/members/member.react.js
@@ -1,168 +1,168 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import {
removeUsersFromThread,
changeThreadMemberRoles,
} from 'lib/actions/thread-actions.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import {
memberIsAdmin,
removeMemberFromThread,
switchMemberAdminRoleInThread,
getAvailableThreadMemberActions,
} from 'lib/shared/thread-utils.js';
import { stringForUser } from 'lib/shared/user-utils.js';
import type { SetState } from 'lib/types/hook-types.js';
import {
type RelativeMemberInfo,
type ThreadInfo,
} from 'lib/types/thread-types.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import css from './members-modal.css';
+import UserAvatar from '../../../avatars/user-avatar.react.js';
import Label from '../../../components/label.react.js';
import MenuItem from '../../../components/menu-item.react.js';
import Menu from '../../../components/menu.react.js';
-import UserAvatar from '../../../components/user-avatar.react.js';
type Props = {
+memberInfo: RelativeMemberInfo,
+threadInfo: ThreadInfo,
+setOpenMenu: SetState,
+isMenuOpen: boolean,
};
function ThreadMember(props: Props): React.Node {
const { memberInfo, threadInfo, setOpenMenu, isMenuOpen } = props;
const userName = stringForUser(memberInfo);
const { roles } = threadInfo;
const { role } = memberInfo;
const onMenuChange = React.useCallback(
menuOpen => {
if (menuOpen) {
setOpenMenu(() => memberInfo.id);
} else {
setOpenMenu(menu => (menu === memberInfo.id ? null : menu));
}
},
[memberInfo.id, setOpenMenu],
);
const dispatchActionPromise = useDispatchActionPromise();
const boundRemoveUsersFromThread = useServerCall(removeUsersFromThread);
const onClickRemoveUser = React.useCallback(
() =>
removeMemberFromThread(
threadInfo,
memberInfo,
dispatchActionPromise,
boundRemoveUsersFromThread,
),
[boundRemoveUsersFromThread, dispatchActionPromise, memberInfo, threadInfo],
);
const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo);
const boundChangeThreadMemberRoles = useServerCall(changeThreadMemberRoles);
const onMemberAdminRoleToggled = React.useCallback(
() =>
switchMemberAdminRoleInThread(
threadInfo,
memberInfo,
isCurrentlyAdmin,
dispatchActionPromise,
boundChangeThreadMemberRoles,
),
[
boundChangeThreadMemberRoles,
dispatchActionPromise,
isCurrentlyAdmin,
memberInfo,
threadInfo,
],
);
const menuItems = React.useMemo(
() =>
getAvailableThreadMemberActions(memberInfo, threadInfo).map(action => {
if (action === 'remove_admin') {
return (
);
}
if (action === 'make_admin') {
return (
);
}
if (action === 'remove_user') {
return (
);
}
return null;
}),
[memberInfo, onClickRemoveUser, onMemberAdminRoleToggled, threadInfo],
);
const userSettingsIcon = React.useMemo(
() => ,
[],
);
const roleName = role && roles[role].name;
const label = React.useMemo(
() => ,
[roleName],
);
const memberContainerClasses = classNames(css.memberContainer, {
[css.memberContainerWithMenuOpen]: isMenuOpen,
});
return (
{userName}
{label}
);
}
export default ThreadMember;
diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js
index 380f62945..9c9c7a9a9 100644
--- a/web/modals/threads/sidebars/sidebar.react.js
+++ b/web/modals/threads/sidebars/sidebar.react.js
@@ -1,101 +1,101 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js';
import { useMessagePreview } from 'lib/shared/message-utils.js';
import { shortAbsoluteDate } from 'lib/utils/date-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './sidebars-modal.css';
+import ThreadAvatar from '../../../avatars/thread-avatar.react.js';
import Button from '../../../components/button.react.js';
-import ThreadAvatar from '../../../components/thread-avatar.react.js';
import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js';
import { useOnClickThread } from '../../../selectors/thread-selectors.js';
type Props = {
+sidebar: ChatThreadItem,
+isLastItem?: boolean,
};
function Sidebar(props: Props): React.Node {
const { sidebar, isLastItem } = props;
const { threadInfo, lastUpdatedTime, mostRecentMessageInfo } = sidebar;
const { unread } = threadInfo.currentUser;
const { popModal } = useModalContext();
const navigateToThread = useOnClickThread(threadInfo);
const onClickThread = React.useCallback(
event => {
popModal();
navigateToThread(event);
},
[popModal, navigateToThread],
);
const sidebarInfoClassName = classNames({
[css.sidebarInfo]: true,
[css.unread]: unread,
});
const previewTextClassName = classNames([
css.longTextEllipsis,
css.avatarOffset,
]);
const lastActivity = React.useMemo(
() => shortAbsoluteDate(lastUpdatedTime),
[lastUpdatedTime],
);
const messagePreviewResult = useMessagePreview(
mostRecentMessageInfo,
threadInfo,
getDefaultTextMessageRules().simpleMarkdownRules,
);
const lastMessage = React.useMemo(() => {
if (!messagePreviewResult) {
return No messages
;
}
const { message, username } = messagePreviewResult;
const previewText = username
? `${username.text}: ${message.text}`
: message.text;
return (
<>
{previewText}
{lastActivity}
>
);
}, [lastActivity, messagePreviewResult, previewTextClassName]);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
);
}
export default Sidebar;
diff --git a/web/modals/threads/subchannels/subchannel.react.js b/web/modals/threads/subchannels/subchannel.react.js
index 8ecd3aa5d..27f72c215 100644
--- a/web/modals/threads/subchannels/subchannel.react.js
+++ b/web/modals/threads/subchannels/subchannel.react.js
@@ -1,89 +1,89 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { type ChatThreadItem } from 'lib/selectors/chat-selectors.js';
import { useMessagePreview } from 'lib/shared/message-utils.js';
import { shortAbsoluteDate } from 'lib/utils/date-utils.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './subchannels-modal.css';
+import ThreadAvatar from '../../../avatars/thread-avatar.react.js';
import Button from '../../../components/button.react.js';
-import ThreadAvatar from '../../../components/thread-avatar.react.js';
import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js';
import { useOnClickThread } from '../../../selectors/thread-selectors.js';
type Props = {
+chatThreadItem: ChatThreadItem,
};
function Subchannel(props: Props): React.Node {
const { chatThreadItem } = props;
const {
threadInfo,
mostRecentMessageInfo,
lastUpdatedTimeIncludingSidebars,
} = chatThreadItem;
const { unread } = threadInfo.currentUser;
const subchannelTitleClassName = classNames({
[css.subchannelInfo]: true,
[css.unread]: unread,
});
const { popModal } = useModalContext();
const navigateToThread = useOnClickThread(threadInfo);
const onClickThread = React.useCallback(
event => {
popModal();
navigateToThread(event);
},
[popModal, navigateToThread],
);
const lastActivity = React.useMemo(
() => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars),
[lastUpdatedTimeIncludingSidebars],
);
const messagePreviewResult = useMessagePreview(
mostRecentMessageInfo,
threadInfo,
getDefaultTextMessageRules().simpleMarkdownRules,
);
const lastMessage = React.useMemo(() => {
if (!messagePreviewResult) {
return No messages
;
}
const { message, username } = messagePreviewResult;
const previewText = username
? `${username.text}: ${message.text}`
: message.text;
return (
<>
{previewText}
{lastActivity}
>
);
}, [lastActivity, messagePreviewResult]);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
);
}
export default Subchannel;
diff --git a/web/modals/threads/thread-picker-modal.react.js b/web/modals/threads/thread-picker-modal.react.js
index 4f658649e..86311a28d 100644
--- a/web/modals/threads/thread-picker-modal.react.js
+++ b/web/modals/threads/thread-picker-modal.react.js
@@ -1,140 +1,140 @@
// @flow
import invariant from 'invariant';
import * as React from 'react';
import { createSelector } from 'reselect';
import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js';
import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors.js';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import css from './thread-picker-modal.css';
+import ThreadAvatar from '../../avatars/thread-avatar.react.js';
import Button from '../../components/button.react.js';
import Search from '../../components/search.react.js';
-import ThreadAvatar from '../../components/thread-avatar.react.js';
import { useSelector } from '../../redux/redux-utils.js';
import Modal, { type ModalOverridableProps } from '../modal.react.js';
type OptionProps = {
+threadInfo: ThreadInfo,
+createNewEntry: (threadID: string) => void,
+onCloseModal: () => void,
};
function ThreadPickerOption(props: OptionProps) {
const { threadInfo, createNewEntry, onCloseModal } = props;
const onClickThreadOption = React.useCallback(() => {
createNewEntry(threadInfo.id);
onCloseModal();
}, [threadInfo.id, createNewEntry, onCloseModal]);
const { uiName } = useResolvedThreadInfo(threadInfo);
return (
);
}
type Props = {
...ModalOverridableProps,
+createNewEntry: (threadID: string) => void,
};
function ThreadPickerModal(props: Props): React.Node {
const { createNewEntry, ...modalProps } = props;
const onScreenThreadInfos = useSelector(onScreenEntryEditableThreadInfos);
const searchIndex = useGlobalThreadSearchIndex();
invariant(
onScreenThreadInfos.length > 0,
"ThreadPicker can't be open when onScreenThreadInfos is empty",
);
const [searchText, setSearchText] = React.useState('');
const [searchResults, setSearchResults] = React.useState>(
new Set(),
);
const searchRef = React.useRef();
React.useEffect(() => {
searchRef.current?.focus();
}, []);
const onChangeSearchText = React.useCallback(
(text: string) => {
const results = searchIndex.getSearchResults(text);
setSearchText(text);
setSearchResults(new Set(results));
},
[searchIndex],
);
const listDataSelector = createSelector(
state => state.onScreenThreadInfos,
state => state.searchText,
state => state.searchResults,
(
threadInfos: $ReadOnlyArray,
text: string,
results: Set,
) =>
text
? threadInfos.filter(threadInfo => results.has(threadInfo.id))
: [...threadInfos],
);
const threads = useSelector(() =>
listDataSelector({
onScreenThreadInfos,
searchText,
searchResults,
}),
);
const threadPickerContent = React.useMemo(() => {
const options = threads.map(threadInfo => (
));
if (options.length === 0 && searchText.length > 0) {
return (
No results for {searchText}
);
} else {
return options;
}
}, [threads, createNewEntry, modalProps.onClose, searchText]);
return (
);
}
export default ThreadPickerModal;
diff --git a/web/navigation-panels/nav-state-info-bar.react.js b/web/navigation-panels/nav-state-info-bar.react.js
index cbab4c660..5727fb925 100644
--- a/web/navigation-panels/nav-state-info-bar.react.js
+++ b/web/navigation-panels/nav-state-info-bar.react.js
@@ -1,67 +1,67 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import type { ThreadInfo } from 'lib/types/thread-types.js';
import ThreadAncestors from './chat-thread-ancestors.react.js';
import css from './nav-state-info-bar.css';
-import ThreadAvatar from '../components/thread-avatar.react.js';
+import ThreadAvatar from '../avatars/thread-avatar.react.js';
type NavStateInfoBarProps = {
+threadInfo: ThreadInfo,
};
function NavStateInfoBar(props: NavStateInfoBarProps): React.Node {
const { threadInfo } = props;
return (
<>
>
);
}
type PossiblyEmptyNavStateInfoBarProps = {
+threadInfoInput: ?ThreadInfo,
};
function PossiblyEmptyNavStateInfoBar(
props: PossiblyEmptyNavStateInfoBarProps,
): React.Node {
const { threadInfoInput } = props;
const [threadInfo, setThreadInfo] = React.useState(threadInfoInput);
React.useEffect(() => {
if (threadInfoInput !== threadInfo) {
if (threadInfoInput) {
setThreadInfo(threadInfoInput);
} else {
const timeout = setTimeout(() => {
setThreadInfo(null);
}, 200);
return () => clearTimeout(timeout);
}
}
return undefined;
}, [threadInfoInput, threadInfo]);
const content = React.useMemo(() => {
if (threadInfo) {
return ;
} else {
return null;
}
}, [threadInfo]);
const classes = classnames(css.topBarContainer, {
[css.hide]: !threadInfoInput,
[css.show]: threadInfoInput,
});
return {content}
;
}
export default PossiblyEmptyNavStateInfoBar;
diff --git a/web/settings/account-settings.react.js b/web/settings/account-settings.react.js
index d310d9004..d07b623e1 100644
--- a/web/settings/account-settings.react.js
+++ b/web/settings/account-settings.react.js
@@ -1,113 +1,113 @@
// @flow
import * as React from 'react';
import { logOut, logOutActionTypes } from 'lib/actions/user-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useStringForUser } from 'lib/hooks/ens-cache.js';
import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js';
import { accountHasPassword } from 'lib/shared/account-utils.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import css from './account-settings.css';
import PasswordChangeModal from './password-change-modal.js';
import BlockListModal from './relationship/block-list-modal.react.js';
import FriendListModal from './relationship/friend-list-modal.react.js';
+import UserAvatar from '../avatars/user-avatar.react.js';
import Button from '../components/button.react.js';
-import UserAvatar from '../components/user-avatar.react.js';
import { useSelector } from '../redux/redux-utils.js';
function AccountSettings(): React.Node {
const sendLogoutRequest = useServerCall(logOut);
const preRequestUserState = useSelector(preRequestUserStateSelector);
const dispatchActionPromise = useDispatchActionPromise();
const logOutUser = React.useCallback(
() =>
dispatchActionPromise(
logOutActionTypes,
sendLogoutRequest(preRequestUserState),
),
[dispatchActionPromise, preRequestUserState, sendLogoutRequest],
);
const { pushModal, popModal } = useModalContext();
const showPasswordChangeModal = React.useCallback(
() => pushModal(),
[pushModal],
);
const openFriendList = React.useCallback(
() => pushModal(),
[popModal, pushModal],
);
const openBlockList = React.useCallback(
() => pushModal(),
[popModal, pushModal],
);
const isAccountWithPassword = useSelector(state =>
accountHasPassword(state.currentUserInfo),
);
const currentUserInfo = useSelector(state => state.currentUserInfo);
const stringForUser = useStringForUser(currentUserInfo);
if (!currentUserInfo || currentUserInfo.anonymous) {
return null;
}
let changePasswordSection;
if (isAccountWithPassword) {
changePasswordSection = (
Password
******
);
}
return (
);
}
export default AccountSettings;
diff --git a/web/settings/relationship/block-list-row.react.js b/web/settings/relationship/block-list-row.react.js
index abeecea27..5faf9e095 100644
--- a/web/settings/relationship/block-list-row.react.js
+++ b/web/settings/relationship/block-list-row.react.js
@@ -1,40 +1,40 @@
// @flow
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useRelationshipCallbacks } from 'lib/hooks/relationship-prompt.js';
import css from './user-list-row.css';
import type { UserRowProps } from './user-list.react.js';
+import UserAvatar from '../../avatars/user-avatar.react.js';
import MenuItem from '../../components/menu-item.react.js';
import Menu from '../../components/menu.react.js';
-import UserAvatar from '../../components/user-avatar.react.js';
function BlockListRow(props: UserRowProps): React.Node {
const { userInfo, onMenuVisibilityChange } = props;
const { unblockUser } = useRelationshipCallbacks(userInfo.id);
const editIcon = ;
return (
);
}
export default BlockListRow;
diff --git a/web/settings/relationship/friend-list-row.react.js b/web/settings/relationship/friend-list-row.react.js
index 5eb42c6c5..a24c92221 100644
--- a/web/settings/relationship/friend-list-row.react.js
+++ b/web/settings/relationship/friend-list-row.react.js
@@ -1,93 +1,93 @@
// @flow
import * as React from 'react';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { useRelationshipCallbacks } from 'lib/hooks/relationship-prompt.js';
import { userRelationshipStatus } from 'lib/types/relationship-types.js';
import css from './user-list-row.css';
import type { UserRowProps } from './user-list.react.js';
+import UserAvatar from '../../avatars/user-avatar.react.js';
import Button from '../../components/button.react.js';
import MenuItem from '../../components/menu-item.react.js';
import Menu from '../../components/menu.react.js';
-import UserAvatar from '../../components/user-avatar.react.js';
const dangerButtonColor = {
color: 'var(--btn-bg-danger)',
};
function FriendListRow(props: UserRowProps): React.Node {
const { userInfo, onMenuVisibilityChange } = props;
const { friendUser, unfriendUser } = useRelationshipCallbacks(userInfo.id);
const buttons = React.useMemo(() => {
if (userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT) {
return (
);
}
if (
userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED
) {
return (
<>
>
);
}
if (userInfo.relationshipStatus === userRelationshipStatus.FRIEND) {
const editIcon = ;
return (
);
}
return undefined;
}, [
friendUser,
unfriendUser,
userInfo.relationshipStatus,
onMenuVisibilityChange,
]);
return (
);
}
export default FriendListRow;
diff --git a/web/sidebar/community-creation/community-creation-modal.react.js b/web/sidebar/community-creation/community-creation-modal.react.js
index 420c46c84..3bd02e609 100644
--- a/web/sidebar/community-creation/community-creation-modal.react.js
+++ b/web/sidebar/community-creation/community-creation-modal.react.js
@@ -1,208 +1,208 @@
// @flow
import * as React from 'react';
import { useDispatch } from 'react-redux';
import { newThread, newThreadActionTypes } from 'lib/actions/thread-actions.js';
import { useModalContext } from 'lib/components/modal-provider.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import { threadTypes } from 'lib/types/thread-types-enum.js';
import type { NewThreadResult } from 'lib/types/thread-types.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js';
import CommunityCreationMembersModal from './community-creation-members-modal.react.js';
import css from './community-creation-modal.css';
+import UserAvatar from '../../avatars/user-avatar.react.js';
import CommIcon from '../../CommIcon.react.js';
import Button, { buttonThemes } from '../../components/button.react.js';
import EnumSettingsOption from '../../components/enum-settings-option.react.js';
-import UserAvatar from '../../components/user-avatar.react.js';
import LoadingIndicator from '../../loading-indicator.react.js';
import Input from '../../modals/input.react.js';
import Modal from '../../modals/modal.react.js';
import { updateNavInfoActionType } from '../../redux/action-types.js';
import { useSelector } from '../../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../../selectors/nav-selectors.js';
const announcementStatements = [
{
statement:
`This option sets the community’s root channel to an ` +
`announcement channel. Only admins and other admin-appointed ` +
`roles can send messages in an announcement channel.`,
isStatementValid: true,
styleStatementBasedOnValidity: false,
},
];
const createNewCommunityLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
function CommunityCreationModal(): React.Node {
const modalContext = useModalContext();
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callNewThread = useServerCall(newThread);
const calendarQueryFunc = useSelector(nonThreadCalendarQuery);
const [errorMessage, setErrorMessage] = React.useState();
const [pendingCommunityName, setPendingCommunityName] =
React.useState('');
const onChangePendingCommunityName = React.useCallback(
(event: SyntheticEvent) => {
setErrorMessage();
setPendingCommunityName(event.currentTarget.value);
},
[],
);
const [announcementSetting, setAnnouncementSetting] = React.useState(false);
const onAnnouncementSelected = React.useCallback(() => {
setErrorMessage();
setAnnouncementSetting(!announcementSetting);
}, [announcementSetting]);
const callCreateNewCommunity = React.useCallback(async () => {
const calendarQuery = calendarQueryFunc();
try {
const newThreadResult: NewThreadResult = await callNewThread({
name: pendingCommunityName,
type: announcementSetting
? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT
: threadTypes.COMMUNITY_ROOT,
calendarQuery,
});
return newThreadResult;
} catch (e) {
setErrorMessage('Community creation failed. Please try again.');
throw e;
}
}, [
announcementSetting,
calendarQueryFunc,
callNewThread,
pendingCommunityName,
]);
const createNewCommunity = React.useCallback(async () => {
setErrorMessage();
const newThreadResultPromise = callCreateNewCommunity();
dispatchActionPromise(newThreadActionTypes, newThreadResultPromise);
const newThreadResult: NewThreadResult = await newThreadResultPromise;
const { newThreadID } = newThreadResult;
await dispatch({
type: updateNavInfoActionType,
payload: {
activeChatThreadID: newThreadID,
},
});
modalContext.popModal();
modalContext.pushModal(
,
);
}, [callCreateNewCommunity, dispatch, dispatchActionPromise, modalContext]);
const megaphoneIcon = React.useMemo(
() => ,
[],
);
const avatarNodeEnabled = false;
let avatarNode;
if (avatarNodeEnabled) {
avatarNode = (
);
}
const createNewCommunityLoadingStatus: LoadingStatus = useSelector(
createNewCommunityLoadingStatusSelector,
);
let buttonContent;
if (createNewCommunityLoadingStatus === 'loading') {
buttonContent = (
);
} else if (errorMessage) {
buttonContent = errorMessage;
} else {
buttonContent = 'Create community';
}
return (
);
}
export default CommunityCreationModal;
diff --git a/web/sidebar/community-drawer-item-community.react.js b/web/sidebar/community-drawer-item-community.react.js
index d7b249ff5..2bad1d8d1 100644
--- a/web/sidebar/community-drawer-item-community.react.js
+++ b/web/sidebar/community-drawer-item-community.react.js
@@ -1,98 +1,98 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import { getCommunityDrawerItemCommunityHandler } from './community-drawer-item-community-handlers.react.js';
import css from './community-drawer-item.css';
import type { DrawerItemProps } from './community-drawer-item.react.js';
import {
getChildren,
getExpandButton,
} from './community-drawer-utils.react.js';
-import ThreadAvatar from '../components/thread-avatar.react.js';
+import ThreadAvatar from '../avatars/thread-avatar.react.js';
function CommunityDrawerItemCommunity(props: DrawerItemProps): React.Node {
const {
itemData: { threadInfo, itemChildren, hasSubchannelsButton, labelStyle },
paddingLeft,
expandable = true,
handlerType,
} = props;
const Handler = getCommunityDrawerItemCommunityHandler(handlerType);
const [handler, setHandler] = React.useState({
onClick: () => {},
isActive: false,
expanded: false,
toggleExpanded: () => {},
});
const children = React.useMemo(
() =>
getChildren({
expanded: handler.expanded,
hasSubchannelsButton,
itemChildren,
paddingLeft,
threadInfo,
expandable,
handlerType,
}),
[
handler.expanded,
hasSubchannelsButton,
itemChildren,
paddingLeft,
threadInfo,
expandable,
handlerType,
],
);
const itemExpandButton = React.useMemo(
() =>
getExpandButton({
expandable,
childrenLength: itemChildren?.length,
hasSubchannelsButton,
onExpandToggled: null,
expanded: handler.expanded,
}),
[expandable, itemChildren?.length, hasSubchannelsButton, handler.expanded],
);
const classes = classnames({
[css.communityBase]: true,
[css.communityExpanded]: handler.expanded,
});
const { uiName } = useResolvedThreadInfo(threadInfo);
const titleLabel = classnames({
[css[labelStyle]]: true,
[css.activeTitle]: handler.isActive,
});
const style = React.useMemo(() => ({ paddingLeft }), [paddingLeft]);
return (
);
}
const MemoizedCommunityDrawerItemCommunity: React.ComponentType =
React.memo(CommunityDrawerItemCommunity);
export default MemoizedCommunityDrawerItemCommunity;
diff --git a/web/sidebar/community-drawer-item.react.js b/web/sidebar/community-drawer-item.react.js
index 3821b57fd..f7d616c8e 100644
--- a/web/sidebar/community-drawer-item.react.js
+++ b/web/sidebar/community-drawer-item.react.js
@@ -1,116 +1,116 @@
// @flow
import classnames from 'classnames';
import * as React from 'react';
import type { CommunityDrawerItemData } from 'lib/utils/drawer-utils.react.js';
import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
import type { HandlerProps } from './community-drawer-item-handlers.react.js';
import { getCommunityDrawerItemHandler } from './community-drawer-item-handlers.react.js';
import css from './community-drawer-item.css';
import {
getChildren,
getExpandButton,
} from './community-drawer-utils.react.js';
-import ThreadAvatar from '../components/thread-avatar.react.js';
+import ThreadAvatar from '../avatars/thread-avatar.react.js';
import type { NavigationTab } from '../types/nav-types.js';
export type DrawerItemProps = {
+itemData: CommunityDrawerItemData,
+paddingLeft: number,
+expandable?: boolean,
+handlerType: NavigationTab,
};
function CommunityDrawerItem(props: DrawerItemProps): React.Node {
const {
itemData: { threadInfo, itemChildren, hasSubchannelsButton, labelStyle },
paddingLeft,
expandable = true,
handlerType,
} = props;
const [handler, setHandler] = React.useState({
onClick: () => {},
expanded: false,
toggleExpanded: () => {},
});
const Handler = getCommunityDrawerItemHandler(handlerType);
const children = React.useMemo(
() =>
getChildren({
expanded: handler.expanded,
hasSubchannelsButton,
itemChildren,
paddingLeft,
threadInfo,
expandable,
handlerType,
}),
[
handler.expanded,
hasSubchannelsButton,
itemChildren,
paddingLeft,
threadInfo,
expandable,
handlerType,
],
);
const itemExpandButton = React.useMemo(
() =>
getExpandButton({
expandable,
childrenLength: itemChildren.length,
hasSubchannelsButton,
onExpandToggled: handler.toggleExpanded,
expanded: handler.expanded,
}),
[
expandable,
itemChildren.length,
hasSubchannelsButton,
handler.toggleExpanded,
handler.expanded,
],
);
const { uiName } = useResolvedThreadInfo(threadInfo);
const titleLabel = classnames({
[css[labelStyle]]: true,
[css.activeTitle]: handler.isActive,
});
const style = React.useMemo(() => ({ paddingLeft }), [paddingLeft]);
return (
<>
{children}
>
);
}
export type CommunityDrawerItemChatProps = {
+itemData: CommunityDrawerItemData,
+paddingLeft: number,
+expandable?: boolean,
+handler: React.ComponentType,
};
const MemoizedCommunityDrawerItem: React.ComponentType =
React.memo(CommunityDrawerItem);
export default MemoizedCommunityDrawerItem;
diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js
index 84c6b445a..cc44faf4a 100644
--- a/web/utils/typeahead-utils.js
+++ b/web/utils/typeahead-utils.js
@@ -1,215 +1,215 @@
// @flow
import classNames from 'classnames';
import * as React from 'react';
import { oldValidUsernameRegexString } from 'lib/shared/account-utils.js';
import { getNewTextAndSelection } from 'lib/shared/mention-utils.js';
import { stringForUserExplicit } from 'lib/shared/user-utils.js';
import type { SetState } from 'lib/types/hook-types.js';
import type { RelativeMemberInfo } from 'lib/types/thread-types.js';
+import UserAvatar from '../avatars/user-avatar.react.js';
import { typeaheadStyle } from '../chat/chat-constants.js';
import css from '../chat/typeahead-tooltip.css';
import Button from '../components/button.react.js';
-import UserAvatar from '../components/user-avatar.react.js';
const webTypeaheadRegex: RegExp = new RegExp(
`(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`,
);
export type TypeaheadTooltipAction = {
+key: string,
+execute: () => mixed,
+actionButtonContent: { +userID: string, +username: string },
};
export type TooltipPosition = {
+top: number,
+left: number,
};
function getCaretOffsets(
textarea: HTMLTextAreaElement,
text: string,
): { caretTopOffset: number, caretLeftOffset: number } {
if (!textarea) {
return { caretTopOffset: 0, caretLeftOffset: 0 };
}
// terribly hacky but it works I guess :D
// we had to use it, as it's hard to count lines in textarea
// and track cursor position within it as
// lines can be wrapped into new lines without \n character
// as result of overflow
const textareaStyle: CSSStyleDeclaration = window.getComputedStyle(
textarea,
null,
);
const div = document.createElement('div');
for (const styleName of textareaStyle) {
div.style.setProperty(styleName, textareaStyle.getPropertyValue(styleName));
}
div.style.display = 'inline-block';
div.style.position = 'absolute';
div.textContent = text;
const span = document.createElement('span');
span.textContent = textarea.value.slice(text.length);
div.appendChild(span);
document.body?.appendChild(div);
const { offsetTop, offsetLeft } = span;
document.body?.removeChild(div);
const textareaWidth = parseInt(textareaStyle.getPropertyValue('width'));
const caretLeftOffset =
offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth
? textareaWidth - typeaheadStyle.tooltipWidth
: offsetLeft;
return {
caretTopOffset: offsetTop - textarea.scrollTop,
caretLeftOffset,
};
}
export type GetTypeaheadTooltipActionsParams = {
+inputStateDraft: string,
+inputStateSetDraft: (draft: string) => mixed,
+inputStateSetTextCursorPosition: (newPosition: number) => mixed,
+suggestedUsers: $ReadOnlyArray,
+textBeforeAtSymbol: string,
+usernamePrefix: string,
};
function getTypeaheadTooltipActions(
params: GetTypeaheadTooltipActionsParams,
): $ReadOnlyArray {
const {
inputStateDraft,
inputStateSetDraft,
inputStateSetTextCursorPosition,
suggestedUsers,
textBeforeAtSymbol,
usernamePrefix,
} = params;
return suggestedUsers
.filter(
suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous',
)
.map(suggestedUser => ({
key: suggestedUser.id,
execute: () => {
const { newText, newSelectionStart } = getNewTextAndSelection(
textBeforeAtSymbol,
inputStateDraft,
usernamePrefix,
suggestedUser,
);
inputStateSetDraft(newText);
inputStateSetTextCursorPosition(newSelectionStart);
},
actionButtonContent: {
userID: suggestedUser.id,
username: stringForUserExplicit(suggestedUser),
},
}));
}
function getTypeaheadTooltipButtons(
setChosenPositionInOverlay: SetState,
chosenPositionInOverlay: number,
actions: $ReadOnlyArray,
): $ReadOnlyArray {
return actions.map((action, idx) => {
const { key, execute, actionButtonContent } = action;
const buttonClasses = classNames(css.suggestion, {
[css.suggestionHover]: idx === chosenPositionInOverlay,
});
const onMouseMove: (
event: SyntheticEvent,
) => mixed = () => {
setChosenPositionInOverlay(idx);
};
return (
);
});
}
function getTypeaheadOverlayScroll(
currentScrollTop: number,
chosenActionPosition: number,
): number {
const upperButtonBoundary = chosenActionPosition * typeaheadStyle.rowHeight;
const lowerButtonBoundary =
(chosenActionPosition + 1) * typeaheadStyle.rowHeight;
if (upperButtonBoundary < currentScrollTop) {
return upperButtonBoundary;
} else if (
lowerButtonBoundary - typeaheadStyle.tooltipMaxHeight >
currentScrollTop
) {
return (
lowerButtonBoundary +
typeaheadStyle.tooltipVerticalPadding -
typeaheadStyle.tooltipMaxHeight
);
}
return currentScrollTop;
}
function getTypeaheadTooltipPosition(
textarea: HTMLTextAreaElement,
actionsLength: number,
textBeforeAtSymbol: string,
): TooltipPosition {
const { caretTopOffset, caretLeftOffset } = getCaretOffsets(
textarea,
textBeforeAtSymbol,
);
const textareaBoundingClientRect = textarea.getBoundingClientRect();
const top: number =
textareaBoundingClientRect.top -
Math.min(
typeaheadStyle.tooltipVerticalPadding +
actionsLength * typeaheadStyle.rowHeight,
typeaheadStyle.tooltipMaxHeight,
) -
typeaheadStyle.tooltipTopOffset +
caretTopOffset;
const left: number =
textareaBoundingClientRect.left -
typeaheadStyle.tooltipLeftOffset +
caretLeftOffset;
return { top, left };
}
export {
webTypeaheadRegex,
getCaretOffsets,
getTypeaheadTooltipActions,
getTypeaheadTooltipButtons,
getTypeaheadOverlayScroll,
getTypeaheadTooltipPosition,
};